Skip to content

fix: handle redis timeouts and cap concurrent backup dispatch - #95

Open
RywJakkraphat wants to merge 3 commits into
Portabase:mainfrom
RywJakkraphat:fix/redis-timeout-concurrency-limit
Open

fix: handle redis timeouts and cap concurrent backup dispatch#95
RywJakkraphat wants to merge 3 commits into
Portabase:mainfrom
RywJakkraphat:fix/redis-timeout-concurrency-limit

Conversation

@RywJakkraphat

@RywJakkraphat RywJakkraphat commented Jul 27, 2026

Copy link
Copy Markdown

Summary

Scheduler and cron sync used .unwrap() on Redis reads/writes and JSON
deserialization, so a Redis timeout under CPU load panicked the process
and crash-looped. Periodic backup dispatch also had no concurrency limit,
letting jobs sharing a cron timestamp saturate host CPU and trigger those
same Redis timeouts.

  • scheduler.rs / cron.rs: replace the panicking .unwrap() calls with
    logged error handling, matching this codebase's existing
    match-and-log convention.
  • dispatcher.rs: gate execute_backup behind an optional
    tokio::sync::Semaphore, configurable via the new MAX_CONCURRENT_BACKUPS
    env var (not yet documented elsewhere in the repo — flagging here).
    Unset = unlimited (unchanged from current behavior), so this is
    opt-in and doesn't change default throughput on upgrade.

Since dispatch() returns as soon as it spawns the backup task, a
saturated semaphore can never delay the cron reschedule in
scheduler_loop — a full queue can't cause missed cron slots.

Refs #94

Tests

  • 1 unit test for execute_task's missing-args error path
  • 2 integration tests (testcontainers Redis) for check_and_update_cron
    and scheduler_loop handling malformed Redis data without panicking
  • 1 test confirming BACKUP_SEMAPHORE is unlimited by default and that
    its acquire/hold/release pattern actually caps concurrency

Every test was verified against the pre-fix code (temporarily reverted)
to confirm it fails at the original bug, then re-verified passing.

Test plan

  • cargo check / cargo clippy / cargo fmt --check — all clean
  • cargo test — all new and existing tests pass

Summary by CodeRabbit

  • New Features

    • Added an optional setting to limit the number of backups running concurrently.
    • Backups now respect the configured concurrency limit when enabled.
  • Bug Fixes

    • Prevented scheduler and cron processing from crashing when Redis operations or stored task data are invalid.
    • Improved validation for missing scheduled-task parameters and configuration errors.
  • Tests

    • Added coverage for backup concurrency limits, malformed scheduler data, and missing task inputs.

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@RywJakkraphat, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 5 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c4d83a71-908b-4693-96e2-eec27a126aca

📥 Commits

Reviewing files that changed from the base of the PR and between 9f352a0 and 8b86b98.

📒 Files selected for processing (9)
  • Cargo.toml
  • src/services/backup/dispatcher.rs
  • src/settings.rs
  • src/tests/services/backup_dispatcher_tests.rs
  • src/tests/services/mod.rs
  • src/tests/utils/mod.rs
  • src/tests/utils/task_manager_tests.rs
  • src/utils/task_manager/cron.rs
  • src/utils/task_manager/scheduler.rs
📝 Walkthrough

Walkthrough

Changes

Backup concurrency limiting

Layer / File(s) Summary
Concurrency configuration
Cargo.toml, src/settings.rs
Adds the optional MAX_CONCURRENT_BACKUPS setting and enables Tokio synchronization support.
Semaphore-gated dispatch
src/services/backup/dispatcher.rs, src/tests/services/*
Adds a global semaphore for dispatched backups and tests unset and bounded concurrency behavior.

Scheduler error handling

Layer / File(s) Summary
Graceful scheduler failures
src/utils/task_manager/cron.rs, src/utils/task_manager/scheduler.rs
Replaces panic-prone Redis, JSON, argument, and configuration handling with logged errors, early returns, skipped tasks, or propagated failures.
Scheduler robustness coverage
src/tests/utils/*
Adds Redis-backed tests for missing arguments and malformed scheduled-task data.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related issues

Possibly related PRs

  • Portabase/agent#47 — Contains closely aligned changes replacing scheduler unwrap calls and validating periodic backup arguments.

Suggested reviewers: rambokdev

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly reflects the two main changes: Redis error handling and limiting concurrent backup dispatch.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai
coderabbitai Bot requested a review from RambokDev July 27, 2026 10:59

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (2)
Cargo.toml (1)

22-22: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Declare Tokio’s time feature directly.

src/tests/services/backup_dispatcher_tests.rs imports tokio::time, but this feature list omits "time". Tokio 1.49.0 gates that module behind the separate feature, so add it explicitly rather than relying on another dependency to enable it. (docs.rs)

Proposed change
-tokio = { version = "1.49.0", features = ["rt", "rt-multi-thread", "macros", "fs", "sync"] }
+tokio = { version = "1.49.0", features = ["rt", "rt-multi-thread", "macros", "fs", "sync", "time"] }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Cargo.toml` at line 22, Update the Tokio dependency declaration to include
the "time" feature explicitly, preserving all existing features so tokio::time
imports compile reliably.
src/tests/services/backup_dispatcher_tests.rs (1)

25-65: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Exercise BackupService::dispatch, not a second semaphore.

This test creates an independent Semaphore and never calls dispatch or execute_backup; it will pass even if production code stops acquiring BACKUP_SEMAPHORE. Add an instrumented dispatcher test or extract the gating logic into a testable helper.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/tests/services/backup_dispatcher_tests.rs` around lines 25 - 65, The test
semaphore_gated_execution_never_exceeds_configured_limit currently validates an
independent semaphore rather than production behavior. Rewrite it to invoke
BackupService::dispatch with an instrumented backup execution path and assert
the observed concurrency stays within the configured limit; alternatively,
extract dispatch’s semaphore-acquire/hold/release logic into a helper and test
that helper while preserving the production wiring through BACKUP_SEMAPHORE.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/settings.rs`:
- Around line 53-65: Update the MAX_CONCURRENT_BACKUPS parsing in the settings
initializer to trim before parsing and treat malformed values as None instead of
panicking; preserve the explicit panic for zero, and cap valid values at
Semaphore::MAX_PERMITS before BACKUP_SEMAPHORE invokes Semaphore::new.

In `@src/tests/services/backup_dispatcher_tests.rs`:
- Around line 12-22: Replace the process-wide BACKUP_SEMAPHORE assertion in
backup_semaphore_defaults_to_unlimited_when_max_concurrent_backups_unset with a
hermetic check: either test the underlying semaphore-construction function using
explicit unset input, or run the check in a subprocess after removing
MAX_CONCURRENT_BACKUPS. Do not depend on CONFIG or BACKUP_SEMAPHORE Lazy
initialization or inherited environment state.

In `@src/utils/task_manager/scheduler.rs`:
- Around line 68-75: Update the scheduling flow around the task dispatch and
rescheduling zadd so a failed reschedule cannot leave the already-executed task
due for another dispatch. Advance or claim the schedule durably before invoking
the backup side effect, while preserving task execution only for successfully
claimed schedule entries.
- Around line 105-108: Update execute_task to initialize the shared context
through a fallible Context::try_new() instead of Context::new(), propagating its
error before constructing ConfigService and BackupService. Add
Context::try_new() so missing or invalid EDGE_KEY returns an error rather than
panicking, while preserving the existing context setup for valid keys.

---

Nitpick comments:
In `@Cargo.toml`:
- Line 22: Update the Tokio dependency declaration to include the "time" feature
explicitly, preserving all existing features so tokio::time imports compile
reliably.

In `@src/tests/services/backup_dispatcher_tests.rs`:
- Around line 25-65: The test
semaphore_gated_execution_never_exceeds_configured_limit currently validates an
independent semaphore rather than production behavior. Rewrite it to invoke
BackupService::dispatch with an instrumented backup execution path and assert
the observed concurrency stays within the configured limit; alternatively,
extract dispatch’s semaphore-acquire/hold/release logic into a helper and test
that helper while preserving the production wiring through BACKUP_SEMAPHORE.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0808691f-3f39-4382-9713-056fa5bfdcfe

📥 Commits

Reviewing files that changed from the base of the PR and between 30f83ba and 9f352a0.

📒 Files selected for processing (9)
  • Cargo.toml
  • src/services/backup/dispatcher.rs
  • src/settings.rs
  • src/tests/services/backup_dispatcher_tests.rs
  • src/tests/services/mod.rs
  • src/tests/utils/mod.rs
  • src/tests/utils/task_manager_tests.rs
  • src/utils/task_manager/cron.rs
  • src/utils/task_manager/scheduler.rs

Comment thread src/settings.rs Outdated
Comment on lines +53 to +65
let max_concurrent_backups = match env::var("MAX_CONCURRENT_BACKUPS") {
Ok(val) if val.trim().is_empty() => None,
Ok(val) => {
let parsed = val
.parse::<usize>()
.expect("MAX_CONCURRENT_BACKUPS must be a valid positive integer");
if parsed == 0 {
panic!("MAX_CONCURRENT_BACKUPS must be at least 1");
}
Some(parsed)
}
Err(_) => None,
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate settings.rs =="
fd -a 'settings\.rs$' . | sed 's#^\./##'

echo "== relevant settings.rs lines =="
if [ -f src/settings.rs ]; then
  cat -n src/settings.rs | sed -n '1,140p'
fi

echo "== imports/usages for MAX_CONCURRENT_BACKUPS and CONFIG =="
rg -n "MAX_CONCURRENT_BACKUPS|CONFIG|Semaphore|MAX_PERMITS" .

echo "== Cargo/Tokio version references =="
for f in Cargo.toml Cargo.lock; do
  if [ -f "$f" ]; then
    echo "--- $f ---"
    rg -n "tokio|MAX_PERMITS" "$f" || true
  fi
done

echo "== Rust parse + bound behavioral probe =="
python3 - <<'PY'
values = ["", "  ", "2", " 2 ", "0", "abc", str(2**64), str(2**32), str(42)]
for v in values:
    ok_trim = None
    try:
        parsed = int(v)
        ok_trim = parsed
    except ValueError:
        ok_trim = "parse-error"
    ok_trim_trim = None
    try:
        parsed = int(v.strip())
        ok_trim_trim = parsed
    except ValueError:
        ok_trim_trim = "parse-error"
    print(f"{v!r}: parse={ok_trim}, parse(trim)={ok_trim_trim}")
PY

Repository: Portabase/agent

Length of output: 9929


🌐 Web query:

Tokio Semaphore::new panic MAX_PERMITS documentation

💡 Result:

In the Tokio library, the Semaphore::new function will panic if the number of requested permits exceeds the constant Semaphore::MAX_PERMITS [1][2]. The constant Semaphore::MAX_PERMITS is defined as usize::MAX >> 3 [1][3]. This limit is enforced to ensure the internal state of the semaphore remains valid; attempts to exceed this limit, whether during initialization via Semaphore::new or when adding permits via Semaphore::add_permits, will trigger a panic [1][4].

Citations:


Make MAX_CONCURRENT_BACKUPS parsing non-panicking.

parse().expect(...) panics on any unparseable input—including " 2 "—before the CONFIG static initializer even finishes. Use val.trim().parse::<usize>(), ignore malformed values by returning None, keep the explicit zero panic, and cap accepted values against Semaphore::MAX_PERMITS before BACKUP_SEMAPHORE calls Semaphore::new.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/settings.rs` around lines 53 - 65, Update the MAX_CONCURRENT_BACKUPS
parsing in the settings initializer to trim before parsing and treat malformed
values as None instead of panicking; preserve the explicit panic for zero, and
cap valid values at Semaphore::MAX_PERMITS before BACKUP_SEMAPHORE invokes
Semaphore::new.

Comment on lines +12 to +22
#[tokio::test]
async fn backup_semaphore_defaults_to_unlimited_when_max_concurrent_backups_unset() {
init_tracing_for_test();

// Neither this test suite nor docker-compose.test.yml sets MAX_CONCURRENT_BACKUPS,
// so the real, process-wide BACKUP_SEMAPHORE must be None: dispatch() must not
// throttle backups unless an operator explicitly opts in.
assert!(
BACKUP_SEMAPHORE.is_none(),
"expected no concurrency cap by default (MAX_CONCURRENT_BACKUPS unset)"
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Make the default-behavior test hermetic.

CONFIG and BACKUP_SEMAPHORE are process-wide Lazy values, so this assertion can fail when the test runner inherits MAX_CONCURRENT_BACKUPS or another test initializes configuration first. Test a pure constructor with explicit inputs, or isolate this check in a subprocess with the variable removed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/tests/services/backup_dispatcher_tests.rs` around lines 12 - 22, Replace
the process-wide BACKUP_SEMAPHORE assertion in
backup_semaphore_defaults_to_unlimited_when_max_concurrent_backups_unset with a
hermetic check: either test the underlying semaphore-construction function using
explicit unset input, or run the check in a subprocess after removing
MAX_CONCURRENT_BACKUPS. Do not depend on CONFIG or BACKUP_SEMAPHORE Lazy
initialization or inherited environment state.

Comment on lines +68 to +75
let result: redis::RedisResult<()> =
conn_clone.zadd(SCHEDULE_KEY, &key, next_ts).await;
if let Err(e) = result {
error!(
"Failed to reschedule task={} key={}: {:?}",
task_clone.task, key, e
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Prevent duplicate backups after rescheduling fails.

The task has already executed when zadd fails. Its old score remains due, so the next scheduler tick dispatches the same backup again. Claim/advance the schedule successfully before starting the side effect, or otherwise add durable idempotency.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/task_manager/scheduler.rs` around lines 68 - 75, Update the
scheduling flow around the task dispatch and rescheduling zadd so a failed
reschedule cannot leave the already-executed task due for another dispatch.
Advance or claim the schedule durably before invoking the backup side effect,
while preserving task execution only for successfully claimed schedule entries.

Comment on lines 105 to +108
let ctx = Arc::new(Context::new());
let config_service = ConfigService::new(ctx.clone());
let backup_service = BackupService::new(ctx.clone());
let config = config_service.load(None).unwrap();
let config = config_service.load(None).map_err(|e| anyhow::anyhow!(e))?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Make context initialization fallible too.

Context::new() still panics when EDGE_KEY is absent or invalid, before config_service.load(None) can return an error. Add a fallible Context::try_new() and propagate it from execute_task.

Proposed direction
- let ctx = Arc::new(Context::new());
+ let ctx = Arc::new(Context::try_new()?);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/task_manager/scheduler.rs` around lines 105 - 108, Update
execute_task to initialize the shared context through a fallible
Context::try_new() instead of Context::new(), propagating its error before
constructing ConfigService and BackupService. Add Context::try_new() so missing
or invalid EDGE_KEY returns an error rather than panicking, while preserving the
existing context setup for valid keys.

RywJakkraphat added a commit to RywJakkraphat/redis-timeout-and-concurrency-limit that referenced this pull request Jul 27, 2026
- settings.rs: extract parse_max_concurrent_backups as a pure,
  pub(crate) function. Trims whitespace before parsing (was panicking
  on values like " 2 "), and panics at boot if the value exceeds
  Semaphore::MAX_PERMITS instead of panicking later inside
  BACKUP_SEMAPHORE's Lazy init on first dispatch. Malformed/zero values
  still panic rather than silently falling back to unlimited -
  deliberate, matches this file's existing fail-fast convention for
  POOLING/CHUNK_SIZE_MB.
- settings.rs: add 7 hermetic unit tests for the extracted function,
  colocated in a #[cfg(test)] mod since it's a pure function, not
  process/Redis-dependent like the rest of src/tests/.
- dispatcher.rs: extract run_with_permit() so the semaphore
  acquire/hold/release logic is shared between dispatch() and its
  test, instead of the test validating a parallel reimplementation.
  Verified this actually catches a regression: temporarily made
  run_with_permit a no-op and confirmed the test fails.
- Cargo.toml: declare tokio's "time" feature explicitly (used by
  scheduler.rs and the new tests) rather than relying on transitive
  enablement from another dependency, matching the same reasoning
  already applied to "sync".

Two other CodeRabbit findings (potential duplicate dispatch if zadd
reschedule fails after a task executes; Context::new() still panicking
on bad EDGE_KEY inside execute_task) are real but out of scope for
this fix: both require larger design changes (schedule-claim ordering,
a new fallible Context::try_new() touching multiple call sites) rather
than a surgical correction, and neither is a regression introduced by
this PR. Left as follow-up work.
@RywJakkraphat
RywJakkraphat force-pushed the fix/redis-timeout-concurrency-limit branch from 24a7e29 to 8b86b98 Compare July 27, 2026 11:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant